Skip to content

feat: add deploy bundle command for downloaded bundles#793

Merged
nealrichardson merged 8 commits into
mainfrom
deploy-bundle
Jun 24, 2026
Merged

feat: add deploy bundle command for downloaded bundles#793
nealrichardson merged 8 commits into
mainfrom
deploy-bundle

Conversation

@nealrichardson

@nealrichardson nealrichardson commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new rsconnect deploy bundle command for deploying a previously built
content bundle (a .tar.gz, such as one downloaded from a Connect server)
directly to a server. The bundle is uploaded as-is and its existing
manifest.json determines the content type and dependencies, making it easy to
copy content from one server to another.

fixes #790

Details

  • New deploy bundle subcommand in main.py, sharing options with
    deploy manifest.
  • Bundle handling in bundle.py: validates the archive, derives a sensible
    default title from the bundle filename (preserving dots).
  • prepare_deploy_metadata git-metadata detection refactored into its own
    helper.
  • Docs: new "Deploying a Downloaded Bundle" section in docs/deploying.md and a
    CHANGELOG entry, including a note that bundles don't carry environment
    variables/secrets and that the target server needs a compatible Python/R
    version.

Testing

  • New tests in tests/test_bundle.py, tests/test_main.py, and
    tests/test_git_metadata.py.

🤖 Generated with Claude Code

nealrichardson and others added 4 commits June 23, 2026 13:13
Add `rsconnect deploy bundle <bundle.tar.gz>` to deploy a previously
built content bundle (such as one downloaded from a Connect server)
directly to a server. The bundle is uploaded as-is; its existing
manifest.json determines the content type and dependencies, making it
easy to copy content between servers.

Rather than extract and re-bundle, this reuses the existing executor
deploy chain: make_bundle simply opens the tarball and deploy_bundle
uploads it unchanged. New bundle.py helpers read the app mode and
default title from the tarball's manifest.json without full extraction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
prepare_deploy_metadata now receives already-detected metadata instead of
a directory to inspect, so callers decide whether to auto-detect git
metadata. The existing deploy commands pass detect_git_metadata(base_dir);
deploy bundle passes an empty dict so no git metadata is auto-attached.

A bundle's location on disk is unrelated to the content's source, so
detecting git metadata from it would attach misleading provenance. Only
explicit --metadata overrides are sent for bundle deployments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- default_title_from_bundle now falls back to the bundle's own filename
  (e.g. "mycontent" from "mycontent.tar.gz") when the manifest has no
  usable entrypoint, instead of the directory the bundle happens to live
  in, which is unrelated to the content's identity (roborev #24, medium).
- Add a CLI-level test (test_deploy_bundle) covering the full deploy flow
  and asserting the tarball is uploaded unchanged (roborev #24, low).
- Add unit tests for the filename fallback and a .tar.gz/.tgz extension
  stripping helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A bundle filename like my.cool.api.tar.gz was truncated to "my.cool":
after stripping the archive extension, _default_title ran a second
rsplit(".", 1) on the result. Extract length enforcement into
_enforce_title_length and format the pre-stripped bundle name directly,
so dotted stems are preserved (roborev #26, low).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-06-24 18:23 UTC

@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
7271 5948 82% 0% 🟢

New Files

No new covered files...

Modified Files

File Coverage Status
rsconnect/bundle.py 86% 🟢
rsconnect/main.py 80% 🟢
TOTAL 83% 🟢

updated for commit: 843eaac by action🐍

Comment thread rsconnect/bundle.py Outdated
return _default_title(filename)


def _strip_bundle_extension(name: str) -> str:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Connect always returns .tar.gz so we don't need this affordance.

Comment thread rsconnect/main.py
Comment thread rsconnect/main.py Outdated
Comment thread docs/CHANGELOG.md Outdated
nealrichardson and others added 3 commits June 24, 2026 08:07
- Revert prepare_deploy_metadata to take directory: Optional[str] and
  detect git metadata internally; bundle deployment passes directory=None
  to skip auto-detection, rather than extracting detect_git_metadata to
  every call site.
- Simplify bundle title fallback to only strip .tar.gz (Connect always
  produces .tar.gz bundles), dropping the .tgz/.tar affordance.
- Collapse the metadata assignment in deploy bundle to a single line.
- Move the deploy bundle CHANGELOG entry to the bottom of "Added".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
open_bundle is a no-op "builder" passed to RSConnectExecutor.make_bundle,
which expects a callable returning a file-like bundle. Document that for
deploy bundle the tarball already exists, so we just open() it and route
through make_bundle to reuse the existing deployment-name and upload flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The raw manifest string was modeled after read_manifest_file, where it is
re-added to a freshly built tarball. deploy bundle uploads the bundle as-is
and never rebuilds it, so every caller discarded the raw string. Return just
the parsed ManifestData.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@marcosnav marcosnav left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great!

One caveat. When deploying, Connect checks if the bundle contains only a single subdirectory, if so, it moves everything from that subdirectory up to the parent and removes the empty subdirectory. It loops until this is no longer the case and this only triggers when the extraction directory contains exactly one entry and that entry is a directory.

That it because it is common for users to end up with tarballs in a shape like:

/my-app
  | -- /bundle
         |-- app.py
         |-- manifest.json

So Connect knows how to work with nested dirs like that, but when we download the bundle from Connect it comes with the original shape.

For those cases, trying to deploy a tar with the current approach in this PR, it fails with:
Error: Bundle "bundle-gradio.tar.gz" does not contain a manifest.json file.

I think we should handle that scenario too here.

Note:
macOS Archive Utility is smart. Similarly, when it detects a single top-level directory inside an archive (in this case ./bundle/), it extracts that directory directly and renames it to match the archive filename. So, when trying this out you could think the tarball has only one level when it does not.

Successful Output for Shiny R .tar.gz bundle
$ uv run rsconnect deploy bundle bundle-shiny-r.tar.gz
Validating server... 	[OK]
Validating app mode... 	[OK]
Making bundle ... 	[OK]
Deploying bundle ... 	[OK]
Saving deployed information... 	[OK]
Building Shiny application...
Bundle created with R version 4.5.2 (~=4.5.0) is compatible with environment Local with R version 4.5.0 from /opt/R/4.5.0/bin/R
Bundle requested R version 4.5.2 (~=4.5.0); using /opt/R/4.5.0/bin/R from Local which has version 4.5.0
Performing manifest.json to packrat transformation.
2026/06/24 17:31:08.494479474 [connect-session] Connect Session v2026.06.0-dev+356-g1126d83992
2026/06/24 17:31:08.494838313 [connect-session] Content GUID: 5b07b7a8-1b6b-4f08-af12-76509242170d
2026/06/24 17:31:08.494852684 [connect-session] Content ID: 108505
2026/06/24 17:31:08.494858224 [connect-session] Bundle ID: 348303
2026/06/24 17:31:08.494863085 [connect-session] Job Key: Kdsy1SkTM8a9zFOo
Job started
Determining session server location ...
Connecting to session server http://127.0.0.1:39291 ...
Connected to session server http://127.0.0.1:39291
Starting content session token refresher (interval: 12h0m0s)
2026/06/24 17:31:08.719797660 Running on host: ip-10-0-35-196
2026/06/24 17:31:08.719807301 Process ID: 2091191
2026/06/24 17:31:08.732908866 Linux distribution: Ubuntu 22.04.5 LTS (jammy)
2026/06/24 17:31:08.737122505 Running as user: uid=998(rstudio-connect) gid=999(rstudio-connect) groups=999(rstudio-connect)
2026/06/24 17:31:08.737132165 Connect version: 2026.06.0-dev+356-g1126d83992

...

2026/06/24 17:31:10.306743827 Installing promises (1.5.0) ...
2026/06/24 17:31:10.317820669 	OK (symlinked cache)
2026/06/24 17:31:10.317930638 Installing bslib (0.10.0) ...
2026/06/24 17:31:10.329127599 	OK (symlinked cache)
2026/06/24 17:31:10.329286582 Installing httpuv (1.6.17) ...
2026/06/24 17:31:10.341528567 	OK (symlinked cache)
2026/06/24 17:31:10.341655698 Installing shiny (1.13.0) ...
2026/06/24 17:31:10.352101718 	OK (symlinked cache)
Completed packrat build using Local against R version: '4.5.0'
Stopped session pings to http://127.0.0.1:39291
Stopping content session token refresher
Job completed
Launching Shiny application...
Deployment completed successfully.
	 Dashboard content URL: https://dogfood.team.pct.posit.it/connect/#/apps/5b07b7a8-1b6b-4f08-af12-76509242170d
	 Direct content URL: https://dogfood.team.pct.posit.it/content/5b07b7a8-1b6b-4f08-af12-76509242170d/
Verifying deployed content... 	[OK]
Successful Output for a FastAPI .tar.gz bundle
$ uv run rsconnect deploy bundle bundle-fastapi.tar.gz
Validating server... 	[OK]
Validating app mode... 	[OK]
Making bundle ... 	[OK]
Deploying bundle ... 	[OK]
Saving deployed information... 	[OK]
Building FastAPI application...
Bundle created with Python version 3.11.4 is compatible with environment Local with Python version 3.11.12 from /opt/python/3.11.12/bin/python3.11
Bundle requested Python version 3.11.4; using /opt/python/3.11.12/bin/python3.11 from Local which has version 3.11.12
2026/06/24 17:33:13.309812022 [connect-session] Connect Session v2026.06.0-dev+356-g1126d83992
2026/06/24 17:33:13.310132708 [connect-session] Received trace context: traceID=020bff2d82831b8c2a7e2e4b7993ce3f
2026/06/24 17:33:13.310145929 [connect-session] Content GUID: 0f233bc8-0c12-4683-86e3-f468bf279118
2026/06/24 17:33:13.310152830 [connect-session] Content ID: 108506
2026/06/24 17:33:13.310158750 [connect-session] Bundle ID: 348304
2026/06/24 17:33:13.310164501 [connect-session] Job Key: dgMtwvLz65EJALFt
2026/06/24 17:33:13.310170881 [connect-session] WARNING: Publishing with rsconnect-python or Publisher, upgrade for the generated manifest.json to follow version constraints best practices.
2026/06/24 17:33:13.310176302 [connect-session] For more details on version matching, see https://docs.posit.co/connect/admin/python/#python-version-matching
Job started
Determining session server location ...
Connecting to session server http://127.0.0.1:46533 ...
Connected to session server http://127.0.0.1:46533
Starting content session token refresher (interval: 12h0m0s)
2026/06/24 17:33:13.427813946 Running on host: ip-10-0-35-196
2026/06/24 17:33:13.439785229 Process ID: 2093117
2026/06/24 17:33:13.455290876 Linux distribution: Ubuntu 22.04.5 LTS (jammy)
2026/06/24 17:33:13.462761127 Running as user: uid=998(rstudio-connect) gid=999(rstudio-connect) groups=999(rstudio-connect)
2026/06/24 17:33:13.466502308 Connect version: 2026.06.0-dev+356-g1126d83992

...

Stopped session pings to http://127.0.0.1:46533
Stopping content session token refresher
Job completed
Launching FastAPI application...
Deployment completed successfully.
	 Dashboard content URL: https://dogfood.team.pct.posit.it/connect/#/apps/0f233bc8-0c12-4683-86e3-f468bf279118
	 Direct content URL: https://dogfood.team.pct.posit.it/content/0f233bc8-0c12-4683-86e3-f468bf279118/
Verifying deployed content... 	[OK]

Connect collapses a bundle whose extraction root holds a single subdirectory,
moving its contents up a level and repeating. Downloaded bundles therefore
commonly nest manifest.json under a top-level directory (e.g.
"bundle/manifest.json"). read_bundle_manifest only looked at the tar root, so
deploying such a bundle failed with "does not contain a manifest.json file"
even though Connect would have accepted it.

Mirror Connect's single-subdirectory collapse when locating the manifest, so
app mode and default title are read correctly. The upload path is unchanged:
the bundle is still sent as-is and Connect performs its own collapse.

Reported by @marcosnav in PR review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nealrichardson

nealrichardson commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

(this was claude)

Great catch, thanks @marcosnav. Fixed in 843eaac.

read_bundle_manifest now mirrors Connect's single-subdirectory collapse when locating the manifest: while the extraction root holds exactly one entry and that entry is a directory, it descends a level and repeats. So a downloaded bundle shaped like bundle/manifest.json (or even more deeply nested) now resolves correctly instead of failing with does not contain a manifest.json file.

The upload path is unchanged — we still send the tarball as-is and let Connect do its own collapse server-side; this only affects how we read the manifest locally for app mode and the default title.

Added tests covering single-level nesting, multi-level nesting, ./-prefixed members, and the non-collapsible case (multiple top-level entries), which correctly still reports the missing manifest since Connect wouldn't collapse it either.

@marcosnav marcosnav left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried it out with a nested dirs bundle and works great, the error went away!

:shipit:

@nealrichardson nealrichardson merged commit 4acf343 into main Jun 24, 2026
23 checks passed
@nealrichardson nealrichardson deleted the deploy-bundle branch June 24, 2026 18:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a command to deploy a previously downloaded bundle (.tar.gz) directly

2 participants